<?php

namespace Desktopd\MUA;


// TCP client-side connection (with TLS support)
// Extending this class may be a bad practice (soft _final_)
class TCPClientConnection {
    // settings
    protected $timeout;
    
    protected $fullTLS = false;
    protected $isInsecure = false;
    protected $tlsEnabled = false;
    protected $isNew = true;
    
    protected $stream;
    protected $host;
    protected $port;
    
    
    public function __construct (TCPClient $client, $host, $port, $flags = 0) {
        $this->fullTLS = ($flags & TCPClient::FLAG_TLS_FULL);
        $this->isInsecure = ($flags & TCPClient::FLAG_TLS_INSECURE);
        
        $connectionTimeout = $client->getConnectionTimeout();
        $this->timeout = $client->getTimeout();
        $ciphers = $client->getCipherList();
        $cafile = $client->getCAFile();
        
        $this->host = $host;
        $this->port = $port;
        
        $options = array(
            'ssl' => array(
                'ciphers' => $ciphers
                , 'verify_peer' => !$this->isInsecure
                , 'verify_peer_name' => !$this->isInsecure
                , 'capture_peer_cert' => true
                , 'capture_peer_cert_chain' => true
            )
        );
        
        if ($cafile) {
            $options['ssl']['cafile'] = $cafile;
        }
        
        $context = stream_context_create($options);
        $errno = 0;
        $errstr = '';
        $protocol = $this->fullTLS ? 'tls' : 'tcp';
        
        $this->stream = stream_socket_client(
            "$protocol://$host:$port"
            , $errno
            , $errstr
            , $connectionTimeout
            , STREAM_CLIENT_CONNECT
            , $context
        );
        
        if (!is_resource($this->stream)) {
            throw new \RuntimeException("Could not connect to the TCP server at port $port on $host");
        }
        
        if ($this->fullTLS) {
            $this->tlsEnabled = true;
            
        }
    }
    
    public function setTimeout ($timeout) {
        if (!is_numeric($timeout) || $timeout < 1) {
            return false;
        }
        
        return $this->timeout = (int) $timeout;
    }
    
    protected function fingerprintCertificate ($cert, $digest = 'sha256') {
        if (function_exists('openssl_x509_fingerprint')) {
            return openssl_x509_fingerprint($cert, $digest, true);
        }
        
        $exported = null;
        openssl_x509_export($cert, $exported);
        if (!$exported) {
            return false;
        }
        
        return hash($digest, base64_decode(str_replace(
            array(
                '-----BEGIN CERTIFICATE-----'
                , '-----END CERTIFICATE-----'
            )
            , ''
            , $exported
        )), true);
    }
    
    // for certificate pinning
    public function requireCertificate ($cert) {
        if (!is_resource($this->stream)) {
            return false;
        }
        
        $options = stream_context_get_options($this->stream);
        if (!isset($options['ssl']['peer_certificate'])) {
            return false;
        }
        
        $captured = $options['ssl']['peer_certificate'];
        
        $required = $this->fingerprintCertificate($cert);
        if ($required === false) {
            return false;
        }
        
        $fingerprint = $this->fingerprintCertificate($captured);
        if ($fingerprint === false) {
            return false;
        }
        
        $salt = openssl_random_pseudo_bytes(32);
        return hash_hmac('sha256', $required, $salt)
            === hash_hmac('sha256', $fingerprint, $salt);
    }
    
    // for certificate pinning
    public function requireCA ($cert) {
        if (!is_resource($this->stream)) {
            return false;
        }
        
        $options = stream_context_get_options($this->stream);
        if (!isset($options['ssl']['peer_certificate_chain'])) {
            return false;
        }
        
        $capturedChain = $options['ssl']['peer_certificate_chain'];
        if (!is_array($capturedChain)) {
            return false;
        }
        
        $required = $this->fingerprintCertificate($cert);
        if ($required === false) {
            return false;
        }
        
        $isOK = false;
        foreach ($capturedChain as $captured) {
            $fingerprint = $this->fingerprintCertificate($captured);
            if ($fingerprint === false) {
                return false;
            }
            
            $salt = openssl_random_pseudo_bytes(32);
            $isOK = $isOK
                || hash_hmac('sha256', $required, $salt)
                    === hash_hmac('sha256', $fingerprint, $salt);
            
        }
        
        return $isOK;
    }
    
    // Returns the SHA-256 fingerprint of the server certificate
    public function getFingerprint ($isRaw = false) {
        if (!is_resource($this->stream)) {
            return false;
        }
        
        $options = stream_context_get_options($this->stream);
        if (!isset($options['ssl']['peer_certificate'])) {
            return false;
        }
        
        $captured = $options['ssl']['peer_certificate'];
        
        return $this->fingerprintCertificate($captured);
    }
    
    public function getCertificate () {
        if (!is_resource($this->stream)) {
            return false;
        }
        
        $options = stream_context_get_options($this->stream);
        if (!isset($options['ssl']['peer_certificate'])) {
            return false;
        }
        
        $cert = $options['ssl']['peer_certificate'];
        if (!is_resource($cert)) return $cert;
        $output = '';
        openssl_x509_export($cert, $output);
        return $output;
    }
    
    // Plain STARTTLS
    public function startTLS () {
        if ($this->fullTLS) {
            // TLS enabled
            return false;
        }
        
        $this->isNew = false;
        $type = STREAM_CRYPTO_METHOD_TLS_CLIENT;
        //echo "Enabling TLS...\r\n";
        if (!stream_socket_enable_crypto($this->stream, true, $type)) {
            return false;
        }
        
        //echo "Done!\r\n";
        
        // success
        return $this->tlsEnabled = true;
    }
    
    public function isTLS () {
        return $this->tlsEnabled === true;
    }
    
    public function getHost () {
        return $this->host;
    }
    
    public function getPort () {
        return $this->port;
    }
    
    public function readLine ($maximum = false) {
        if (!is_resource($this->stream)) {
            return false;
        }
        
        $this->isNew = false;
        
        if (is_numeric($maximum) && $maximum > 1) {
            return fgets($this->stream, ceil($maximum));
        } else {
            return fgets($this->stream);
        }
    }
    
    public function readBytes ($bytes, $strictLength = false, $maxFailure = 5) {
        if (!is_resource($this->stream)) {
            return false;
        }
        
        if (!is_numeric($bytes) || $bytes < 0) {
            throw new \BadMethodCallException("Positive integer required: $bytes");
        }
        
        if (!is_numeric($maxFailure) || $maxFailure < 0) {
            throw new \BadMethodCallException("Positive integer required: $maxFailure");
        }
        
        $bytes = ceil($bytes);
        
        $failure = 0;
        $read = '';
        while (strlen($read) < $bytes) {
            if (!is_resource($this->stream)) {
                return false;
            }
            
            if (feof($this->stream)) {
                break;
            }
            
            $result = fread($bytes - strlen($read));
            if ($result === false) {
                $failure++;
                if ($failure > $maxFailure) break;
                continue;
            }
            
            $this->isNew = false;
            $read .= $result;
        }
        
        if ($strictLength && strlen($read) < $bytes) {
            return false;
        }
        
        return $read;
    }
    
    public function write ($data) {
        if (!is_resource($this->stream)) {
            return false;
        }
        
        $data = (string) $data;
        $length = strlen($data);
        $written = 0;
        
        while ($written < $length) {
            if (!is_resource($this->stream)) {
                return false;
            }
            
            $result = fwrite($this->stream, substr($data, $written));
            $this->isNew = false;
            
            if ($result === false) {
                return $written;
            }
            
            $written += $result;
        }
        
        return $written;
    }
    
    public function isEOF () {
        if (!is_resource($this->stream)) {
            return true;
        }
        
        return feof($this->stream);
    }
    
    // returns false if modified
    public function isNew () {
        return $this->isNew;
    }
    
    public function isConnected () {
        if (!is_resource($this->stream)) {
            return false;
        }
        
        $status = stream_get_meta_data($this->stream);
        return !$status['eof'];
    }
    
    public function __destruct () {
        if (!is_resource($this->stream)) {
            return;
        }
        
        $this->isNew = false;
        fclose($this->stream);
    }
}


// vim: ts=4 et ai

